fix(objectql): ScopedContext.transaction 补齐 ADR-0067 D2 的 ambient join (#6168) - #6403
Merged
Merged
Conversation
#6168) `ObjectQL.transaction()` 的第一件事一直是 D2 的 join 判定:已有 ambient 事务 就在其中运行回调并报 `owned: false`,不再自开嵌套的 driver 事务。 `ScopedContext.transaction()` —— 同一原语的第二份实现,hook / action 体里的 `ctx.api.transaction(fn)` —— 没有这一支,直接取默认驱动 `beginTransaction()`。 它自己的 TSDoc 称自己是「同一件事的第二份实现」并逐条对齐了 ADR-0119 D1 的 caveat,唯独 join 这条一直没对齐。本次补上:同一分支、同一位置(在驱动查找和 `opts.require` 之前 —— ambient 事务本身就是事务,声明「不能没有事务」的调用者 由 join 满足)。 行为变化:内层从此随外层回滚。此前一个由 `engine.transaction()` 触发的 hook, 其体内的 `ctx.api.transaction(fn)` 会另开一个事务并自行 commit,写入因此 **存活过外层回滚** —— 调用者被告知工作单元已撤销,而其中若干行仍在,无报错也 无日志;同时它占住第二条连接,正是 D2 要避开的单连接池死锁。 join 只读引擎的 ambient store,因此离散 begin/commit/rollback 三件套(刻意不写 该 store)的显式句柄不可见、不会被误当成 ambient —— 该边界连同 #6167 的交叉 引用一并写入 TSDoc。 测试:`engine-ambient-transaction.test.ts` 新增 5 例(含回滚持久性钉子:内层写 在外层回滚后必须消失,由一个 rollback-honest 的 driver 双替按句柄暂存/丢弃来 测),`engine-transaction-contract.test.ts` 新增 2 例契约对齐, `engine-transaction-same-origin.test.ts` 新增 2 例证实 #5351 同源判定在 join 后 走外层属主、拒绝与豁免形状不变。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
ADR-0067 D2 理由的另一半:第二次 `beginTransaction` 会向连接池要第二条连接, 而单连接池(D2 点名的 knex/SQLite 那个)给不出来。真实池在此**阻塞**——那就是 死锁——但如实建模阻塞的测试只能靠 vitest 超时判红:慢,且并行下不稳。 故双替建模「size=1 且带 acquire 超时」的池:第二次签出直接拒绝而不是排队。 拒绝是对挂起的替身;两种建模下被如实测量的都是同一个致因——到底有没有去要 第二条连接。 反向验证:移除 join 支后本例翻红,报 `pool exhausted: no connection available (max=1)`,栈为 registerHook handler → ScopedContext.transaction → beginTransaction,正是 issue 所述机制。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
baozhoutao
marked this pull request as ready for review
August 7, 2026 17:06
baozhoutao
enabled auto-merge
August 7, 2026 17:07
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6168
问题
ObjectQL.transaction()的第一件事是 ADR-0067 D2 的 join 判定:已有 ambient 事务就在其中跑回调并报owned: false,不自开嵌套的 driver 事务。ScopedContext.transaction()—— 同一原语的第二份实现,hook / action 体里的ctx.api.transaction(fn)—— 没有这一支,直接取默认驱动beginTransaction()。它自己的 TSDoc 称自己是「同一件事的第二份实现」,并逐条对齐了 ADR-0119 D1 的两条 caveat,唯独 join 这条从未对齐;方法体内甚至留着一行注释明说「本面永远 OPEN(没有自己的 D2 join 支)」。后果两条,都是 D2 存在的理由:
#5696 的
owned信号在这一面永远报true。信号没说谎(它确实每次都自开),说谎的是被它如实描述的行为。修法
在
ScopedContext.transaction顶部补上与引擎面同形、同位的 join 支:opts.require之前,与引擎面一致 —— 有 ambient 事务就是有事务,声明「不能没有事务」的调用者由 join 满足,降级路径不可达;ScopedContext(而非只靠 ambient store),理由与引擎面相同:buildDriverOptions优先读显式句柄,且显式句柄能穿过 ALS 穿不过的异步边界;transactionCoversDriverFor仍按外层属主归属,ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因) #5351 同源判定不变;{ owned: false }。TSDoc 同步更新,并写明声明式边界:join 只读引擎 ambient
txStore,而离散beginTransaction/commit/rollback三件套刻意不写该 store(句柄跨setImmediate显式穿线,ALS 不存活),因此三件套句柄在此不可见、不会被误当成 ambient —— 这正是 issue 要求的那条约束。交叉引用 #6167。一处与 issue 正文的出入(如实记录)
issue 把可达路径写作「沙箱 hook 体里的
ctx.api.transaction」。实读packages/runtime/src/sandbox/quickjs-runner.ts:526-600:QuickJS 面的ctx.api.transaction(fn)是 VM 侧糖,底下驱动__txBegin/__txCommit/__txRollback三个 host leaf,走的是三件套,不是本方法。所以 VM 内的 body 不在本次 join 的覆盖范围内。本方法真正的可达面是进程内 body:
HookContext.api就是ScopedContext(ObjectQL.buildHookApi),action body 的ctx.api同样是它(packages/runtime/src/action-execution.ts:868的buildActionApi),外加任何直接engine.createContext(...).transaction(...)的插件/应用代码。缺陷本身与 issue 所述完全一致,只是可达面比正文写的窄一档。测试用的是真 hook(registerHook注册afterInsert,在engine.transaction()内被写触发),不是createContext替身。测试(9 例新增)
engine-ambient-transaction.test.ts(+6):owned: false、只有一次 begin;owned: true)—— 回归;回滚钉子需要一个 rollback-honest 的 driver 双替:带句柄的写按句柄暂存,
commit落库、rollback丢弃。文件里原有的双替只记录 rollback 被调用过,那只能钉「回滚被调用」,钉不到本 issue 真正关心的事实 —— 事后那行还在不在。engine-transaction-contract.test.ts(+2):契约对齐(join 的句柄与信号;require: true下由 join 满足而非拒绝)。原有用例标题里「本面永远自开」的说法已失效,改为reports owned: true when it opens one。engine-transaction-same-origin.test.ts(+2):join 后 #5351 判定走外层属主(跨源业务写被拒,transactionDatasource为primary;系统台账照旧豁免、无外来句柄、一条 debug)。反向验证(先立预测,后跑)
预测:移除 join 支 → ambient 文件的 join 三例 + 契约两例翻红,且回滚例的红呈「残留」形状;无 ambient 与已关闭两例保持绿;同源两例保持绿(它们量的是交互,不是 join 本身)。
实测逐条吻合:
单连接池例单独反验,报
pool exhausted: no connection available (max=1),栈正是 issue 所述机制:范围外发现(PD #10,已另开单,未在本 PR 修改)
__txBegin)同样不 join ambient 事务。上面那节的另一面:VM 侧 body 走三件套,因此本 PR 修不到它,而三件套刻意不用 ALS(跨setImmediate不存活),所以那边不是加个 if 就能了事 —— 需要先定「ambient 句柄何时捕获」「join 后__txCommit如何弃权」两个契约级问题。与 事务句柄没有属主可查,同源校验对「引擎没开过的句柄」只能弃权(#5351 的已知边界) #6167(同源校验对不可归属句柄弃权)不是同一件事,别合并。门禁
pnpm --filter @objectstack/objectql test140 文件 / 2318 通过;typecheck干净;check:engine-double-contractOK(80 pinned / 133 DEBT / 4 exempt,未变动);check:durability-log-levelOK;check-nul-bytesOK。已 mergeorigin/main(761a0ba)后重跑。